feat(contacts): crop profile picture before saving - #397
Conversation
Adds a pan/pinch crop step between picking a photo (library or file import) and saving it as a contact's avatar, instead of using the picked image as-is. Crops via UIImage.draw(in:) rather than raw CGImage cropping so EXIF-rotated photos crop the same region shown in the on-screen preview.
The committed generated file didn't match current swiftgen output ordering, tripping the codegen CI check.
2c6bf03 to
57aa27d
Compare
|
Note on CI: SPM Package Tests failed on `ConnectionManagerReconnectAbandonmentTests.swift` ("watchdog natural exit nils the task and preserve re-arms"), asserting on reconnection-watchdog task/generation state. This file isn't touched by this PR (diff only spans `AvatarCropView.swift`, `ContactDetailView.swift`, and localization/generated strings), and it was last modified by unrelated BLE reconnection commits. Looks like a timing-sensitive/flaky assertion rather than a regression from this change — flagging in case a re-run is needed. edit: all tests pass Code Generation Checks, Formatting and Linting, and Xcode Build all pass. |
Avi0n
left a comment
There was a problem hiding this comment.
Thanks for this PR, nice feature add. AI code review below:
| errorMessage = L10n.Contacts.Contacts.Detail.Avatar.invalidImage | ||
| return | ||
| } | ||
| avatarImageToCrop = image |
There was a problem hiding this comment.
presentCropSheet sets showAvatarCropSheet = true in the same turn as the PhotosPicker / fileImporter dismissal. This file already documents that presenting one modal while another is still dismissing is a race (showRepeaterAdminAuth waits for onDismiss before setting activeSheet). The crop cover can fail to appear, especially on the file-importer path where Data(contentsOf:) returns before the importer animation ends. The author tested both entry points, so this may be intermittent rather than always broken.
Suggestion: Hold the decoded image as pending state. Present the cover only after the picker/importer isPresented flag becomes false (or after an onDismiss). Prefer fullScreenCover(item:) with an Identifiable wrapper so the cover cannot open empty.
| } | ||
| } | ||
|
|
||
| private func presentCropSheet(data: Data) { |
There was a problem hiding this comment.
presentCropSheet calls UIImage(data:) on the main actor and stores the result in @State. A 12–48MP camera photo can hitch the UI and hold ~100–200MB while the crop screen is open. The old path decoded inside Task.detached in processAvatarImage and immediately downscaled to 512pt. This is a regression on memory and main-thread work. ImageURLDetector.downsampledImage(from:) already shows the ImageIO thumbnail pattern used elsewhere in the app.
Suggestion: Decode off the main actor. Downsample with ImageIO to a display-sized max pixel dimension before assigning avatarImageToCrop. Crop from that bounded image (512–1024px is enough; processAvatarImage already caps at 512). Nil avatarImageToCrop when the cover dismisses.
| onCancel: { showAvatarCropSheet = false }, | ||
| onComplete: { cropped in | ||
| showAvatarCropSheet = false | ||
| Task { await saveAvatar(data: cropped.jpegData(compressionQuality: 0.9) ?? Data()) } |
There was a problem hiding this comment.
Confirm re-encodes the crop as JPEG at quality 0.9 and 1024px, then saveAvatar → processAvatarImage decodes it again, scales to 512, and re-encodes at 0.8. The first JPEG is thrown away. jpegData(...) ?? Data() also turns a failed encode into empty data; processAvatarImage then fails with invalidImage.
Suggestion: Pass the UIImage (or PNG/raw pixel data) into the existing processor, or render the crop at 512px and skip the extra JPEG. If jpegData is nil, set errorMessage and do not call saveAvatar.
| } | ||
|
|
||
| private var magnificationGesture: some Gesture { | ||
| MagnificationGesture() |
There was a problem hiding this comment.
MagnificationGesture is deprecated as of iOS 17 (MagnifyGesture replaces it). Deployment is iOS 18, so this will warn. During the pinch, scale * pinchDelta is not clamped to 1...4, so the live preview can shrink below fill and show empty area inside the circle until onEnded.
Suggestion: Switch to MagnifyGesture. Clamp the live factor the same way onEnded does: min(max(scale * pinchDelta, 1), 4). Put those limits on named constants next to cropSize.
|
|
||
| var body: some View { | ||
| NavigationStack { | ||
| GeometryReader { _ in |
There was a problem hiding this comment.
GeometryReader { _ in ignores its proxy. The inner dimmingMask already has its own GeometryReader. The outer reader only forces expansion that .frame(maxWidth: .infinity, maxHeight: .infinity) already provides.
Suggestion: Remove the outer GeometryReader and keep the ZStack plus the max frame.
Fixes the modal-presentation race between the picker/importer sheets and the crop cover, decodes and downsamples off the main actor via ImageIO instead of full-resolution UIImage(data:), removes the redundant JPEG re-encode by passing the cropped UIImage straight to the existing avatar processor, swaps the deprecated MagnificationGesture for MagnifyGesture with a clamped live pinch value, and drops an unused outer GeometryReader. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
|
Pushed a follow-up commit addressing all 5 review comments:
Verified |
Description
Adds a crop step to the contact avatar picker flow. Previously, an image picked via the photo library or file importer was saved directly as the contact's profile picture with no way to reframe it. This PR inserts a "Move and Scale" screen between picking and saving, so the user can pan/zoom the image within a circular guide before it's committed.
Overview of Changes
AvatarCropView: a full-screen pan/pinch-to-zoom crop UI with a circular guide, presented viafullScreenCover.ContactDetailView: both avatar-import paths (photo picker and file importer) now route through a newpresentCropSheet(data:)step instead of callingsaveAvatardirectly; the crop view'sonCompletecallback re-encodes the cropped result as JPEG and proceeds to the existing save path unchanged.Testing
swiftlint lintpasses clean.MC1Testssuite passes locally, with two pre-existing failures unrelated to this change (confirmed identical ondevtip, no diff touches these files): a naming-shadow compile issue inMessageLinkTokenizerTests.swift, and twoURLSafetyCheckerTestscases that require live DNS resolution unavailable in this sandbox.Tested on
Checklist
CONTRIBUTING.md